Skip to content

Conversation

@fabioh8010
Copy link
Contributor

@fabioh8010 fabioh8010 commented Mar 3, 2025

Details

#615 (comment)

This PR:

  • Changes Onyx.merge()/Onyx.update() batching logic to replace the current value of a nested property after a null change in it, so the subsequent updates of that batch in this nested property can fully reset its data.
  • Introduces OnyxMerge file that will handle the merging of data in different ways depending on the platform
    • For native, we use mergeItem that will take advantage of JSON_PATCH and JSON_REPLACE SQL operations to merge the object in a performant way. JSON_PATCH operations were already being used before, and now we also use JSON_REPLACE operations to fully replace the marked nested objects when merging data.
    • For web, we use setItem since the object was already merged with its changes before.
  • Several changes to fastMerge/mergeObject in order to mark the nested objects we need to fully replace, more details are commented in the functions.
  • Removes OnyxUtils.removeNullValues since it won't be necessary anymore after the changes in this PR.
  • Creates additional OnyxUtils.mergeChanges/OnyxUtils.mergeAndMarkChanges to handle the batching of merge changes or just merge data into a value.
  • Several unit tests, comments added and types improved.

Related Issues

#615

Automated Tests

Unit tests were added to cover these changes.

Manual Tests

Same as Expensify/App#55199.

Author Checklist

  • I linked the correct issue in the ### Related Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android / native
    • Android / Chrome
    • iOS / native
    • iOS / Safari
    • MacOS / Chrome / Safari
    • MacOS / Desktop
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick)
    • I verified that the left part of a conditional rendering a React component is a boolean and NOT a string, e.g. myBool && <MyComponent />.
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.js or at the top of the file that uses the constant) are defined as such
  • I verified that if a function's arguments changed that all usages have also been updated correctly
  • If a new component is created I verified that:
    • A similar component doesn't exist in the codebase
    • All props are defined accurately and each prop has a /** comment above it */
    • The file is named correctly
    • The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone
    • The only data being stored in the state is data necessary for rendering and nothing else
    • If we are not using the full Onyx data that we loaded, I've added the proper selector in order to ensure the component only re-renders when the data it is using changes
    • For Class Components, any internal methods passed to components event handlers are bound to this properly so there are no scoping issues (i.e. for onClick={this.submit} the method this.submit should be bound to this in the constructor)
    • Any internal methods bound to this are necessary to be bound (i.e. avoid this.submit = this.submit.bind(this); if this.submit is never passed to a component event handler like onClick)
    • All JSX used for rendering exists in the render method
    • The component has the minimum amount of code necessary for its purpose, and it is broken down into smaller components in order to separate concerns and functions
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.
  • I have checked off every checkbox in the PR author checklist, including those that don't apply to this PR.

Screenshots/Videos

Android: Native
Screen.Recording.2025-03-25.at.22.13.11-compressed.mov
Android: mWeb Chrome

I'm having problems with my emulators when opening the Chrome app (they crash instantly), so I couldn't record videos for this platform.

iOS: Native
Screen.Recording.2025-03-25.at.22.29.49-compressed.mov
iOS: mWeb Safari
Screen.Recording.2025-03-25.at.22.34.02-compressed.mov
MacOS: Chrome / Safari
Screen.Recording.2025-03-25.at.22.36.15-compressed.mov
Screen.Recording.2025-03-25.at.22.37.28-compressed.mov
MacOS: Desktop
Screen.Recording.2025-03-25.at.22.43.39-compressed.mov

@fabioh8010
Copy link
Contributor Author

Strangely the should replace the old value after a null merge in a nested property when batching updates and should replace the old value after a null merge in a nested property when batching merges tests are partially passing, which I was not expecting to happen.

Maybe the fastMerge/mergeObject original logic are partially working somehow? I need to investigate.

@fabioh8010
Copy link
Contributor Author

My tests were "wrong", now it will output the errors in the two scenarios of both Onyx.update() and Onyx.merge()

@fabioh8010 fabioh8010 changed the title [WIP] Replace old value after null merges in nested properties when batching merges/updates Replace old value after null merges in nested properties when batching merges/updates Mar 30, 2025
@fabioh8010 fabioh8010 marked this pull request as ready for review March 30, 2025 18:51
@fabioh8010 fabioh8010 requested a review from a team as a code owner March 30, 2025 18:51
@fabioh8010
Copy link
Contributor Author

cc @chrispader @neil-marcellini

@melvin-bot melvin-bot bot requested review from lakchote and removed request for a team March 30, 2025 18:51
Copy link
Contributor

@lakchote lakchote left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left some comments

lib/utils.ts Outdated
// To achieve this, we first mark these nested objects with an internal flag. With the desired objects
// marked, when calling this method again with "shouldReplaceMarkedObjects" set to true we can proceed
// to effectively replace them in the next condition.
if (isBatchingMergeChanges && targetValue === null) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we do something like this?

Suggested change
if (isBatchingMergeChanges && targetValue === null) {
if (isBatchingMergeChanges && !targetValue) {

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should explicitly check for null. undefined values are not supposed to delete a key. Effectively, they should never be in store anyway, but i think making this explicit makes more sense

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We did that previously, but actually for this logic, we might want to rethink that.

If we merge a nested object with an existing key, we would expect undefined values to be set, rather than discarded.

So in this case i would still explicitly check for if (targetValue == null) (which includes undefined values)

Copy link
Contributor

@chrispader chrispader left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! I added a few comments around the SQLite part and tests, but this has a GO from me!

lib/utils.ts Outdated
// To achieve this, we first mark these nested objects with an internal flag. With the desired objects
// marked, when calling this method again with "shouldReplaceMarkedObjects" set to true we can proceed
// to effectively replace them in the next condition.
if (isBatchingMergeChanges && targetValue === null) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should explicitly check for null. undefined values are not supposed to delete a key. Effectively, they should never be in store anyway, but i think making this explicit makes more sense

lib/utils.ts Outdated
// To achieve this, we first mark these nested objects with an internal flag. With the desired objects
// marked, when calling this method again with "shouldReplaceMarkedObjects" set to true we can proceed
// to effectively replace them in the next condition.
if (isBatchingMergeChanges && targetValue === null) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We did that previously, but actually for this logic, we might want to rethink that.

If we merge a nested object with an existing key, we would expect undefined values to be set, rather than discarded.

So in this case i would still explicitly check for if (targetValue == null) (which includes undefined values)

Comment on lines 80 to 104
return this.multiGet(nonNullishPairsKeys).then((storagePairs) => {
// multiGet() is not guaranteed to return the data in the same order we asked with "nonNullishPairsKeys",
// so we use a map to associate keys to their existing values correctly.
const existingMap = new Map<OnyxKey, OnyxValue<OnyxKey>>();
// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < storagePairs.length; i++) {
existingMap.set(storagePairs[i][0], storagePairs[i][1]);
}

const newPairs: KeyValuePairList = [];

// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < nonNullishPairs.length; i++) {
const key = nonNullishPairs[i][0];
const newValue = nonNullishPairs[i][1];

const existingValue = existingMap.get(key) ?? {};

const mergedValue = utils.fastMerge(existingValue, newValue, true, false, true);

newPairs.push([key, mergedValue]);
}

return this.multiSet(newPairs);
});
Copy link
Contributor

@chrispader chrispader Mar 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm really not sure about the performance implications of this change on native.

I know it is hard to have SQLite handle merges with through ON CONFLICT DO UPDATE and JSON_PATCH, but this was supposed to add significant performance gains, since we can basically offload all of the merging (at least with the existing values in store) to the low-level C++ implementation of SQLite. Ideally we would want to let SQLite handle as much of merging/batching as possible.

I see though, that we already had a lot of exceptions to this before and that we still always needed to "pre-merge" in order to broadcast the update, so i think it should be fine for now. In a bigger re-design we could tackle and improve all of this, to make sure that each platform is facilitated to it's fullest.

Still, do we have any benchmarks around how this affects performance of simple Onyx.merge operations?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I think it would be a good idea to measure performance in the app before and after this change to make sure it doesn't cause a large performance hit.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 but I am less convinced that this is "fine for now". It feels like we are undoing work that we had a good reason to do at some point. I trust that we are moving in a good direction, but would rather let some benchmarks do the talking.

@neil-marcellini neil-marcellini self-requested a review April 1, 2025 21:14
Copy link
Contributor

@neil-marcellini neil-marcellini left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with the changes others suggested. I would like to see the tests cleaned up a little and agree with Chris that we should measure the performance impact of this.

I'm also going to request a review from @marcaaron now that he is back and because he has a good perspective about how Onyx should work.

Comment on lines 80 to 104
return this.multiGet(nonNullishPairsKeys).then((storagePairs) => {
// multiGet() is not guaranteed to return the data in the same order we asked with "nonNullishPairsKeys",
// so we use a map to associate keys to their existing values correctly.
const existingMap = new Map<OnyxKey, OnyxValue<OnyxKey>>();
// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < storagePairs.length; i++) {
existingMap.set(storagePairs[i][0], storagePairs[i][1]);
}

const newPairs: KeyValuePairList = [];

// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < nonNullishPairs.length; i++) {
const key = nonNullishPairs[i][0];
const newValue = nonNullishPairs[i][1];

const existingValue = existingMap.get(key) ?? {};

const mergedValue = utils.fastMerge(existingValue, newValue, true, false, true);

newPairs.push([key, mergedValue]);
}

return this.multiSet(newPairs);
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I think it would be a good idea to measure performance in the app before and after this change to make sure it doesn't cause a large performance hit.

@marcaaron
Copy link
Contributor

now we have some customizations (this very PR) that is impossible to replicate with JSON_PATCH

Can you please update the description to mention which customizations are not compatible with JSON_PATCH? I will review today, thanks!

@marcaaron
Copy link
Contributor

marcaaron commented Apr 2, 2025

Is it possible to reword some of this for clarity?

Changes our merging strategy (defined in applyMerge/fastMerge/mergeObject) to have two additional flags: isBatchingMergeChanges and shouldReplaceMarkedObjects. The isBatchingMergeChanges flag will be used when batching any queued merge changes, as it has a special logic to handle the replacement of old values after null merges.

Specifically, "handle the replacement of old values after null merges" didn't quite make sense to me.

The shouldReplaceMarkedObjects flag will be used when applying this batched merge changes to the existing value, using this special logic to effectively replace the old values we desire.

A part of me also finds the description a bit confusing. Maybe you can also explain what a "marked object" is in the description? Thanks!

Copy link
Contributor

@marcaaron marcaaron left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is generally looking good. Thanks for the work on it @fabioh8010 @chrispader. My main feedback (that @neil-marcellini already called out) would be to make sure that JSON_PATCH in the native provider is something we can confidently get rid of. If so, then 👍.

Comment on lines 80 to 104
return this.multiGet(nonNullishPairsKeys).then((storagePairs) => {
// multiGet() is not guaranteed to return the data in the same order we asked with "nonNullishPairsKeys",
// so we use a map to associate keys to their existing values correctly.
const existingMap = new Map<OnyxKey, OnyxValue<OnyxKey>>();
// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < storagePairs.length; i++) {
existingMap.set(storagePairs[i][0], storagePairs[i][1]);
}

const newPairs: KeyValuePairList = [];

// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < nonNullishPairs.length; i++) {
const key = nonNullishPairs[i][0];
const newValue = nonNullishPairs[i][1];

const existingValue = existingMap.get(key) ?? {};

const mergedValue = utils.fastMerge(existingValue, newValue, true, false, true);

newPairs.push([key, mergedValue]);
}

return this.multiSet(newPairs);
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 but I am less convinced that this is "fine for now". It feels like we are undoing work that we had a good reason to do at some point. I trust that we are moving in a good direction, but would rather let some benchmarks do the talking.

@ikevin127

This comment was marked as outdated.

Copy link
Contributor

@ikevin127 ikevin127 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧪 E/App Manual Review Report

Note

  • performed the testing by building this PR's react-native-onyx and replacing the built dist in E/App
  • tests will be performed on all devices: Android & iOS: HybridApp (Native), Android & iOS: mWeb, Web and Desktop

SignUp / Login Flow

  • ✅ on clear cache, sign up with a new account (public & private domain)
  • ✅ while logged-in, logout and sign up with a new account (public & private domain)
  • ✅ on clear cache, login with an existing account (public & private domain)
  • ✅ while logged-in, login with another existing account (public & private domain)
  • ✅ while anonymous in a public room, login / sign-up
  • ✅ switch between ND / OD and back
    • once logged-in / signed-up -> navigate through the app to verify everything looks as expected

Troubleshoot: Clear Cache and Restart Functionality

  • ✅ on a new account, start / create / add some messages in a: selfDM, 1-1 DM, workspace chat, group & room chats
    • verify that upon Clear cache and restart trigger, the LHN report previews look as expected for each of the 'start / create / add messages' report types

Report Actions (aka Messages / Comments: these directly use Onyx's merge/update)

  • ✅ post / update / delete a message on a report / report thread (including offline -> online transition)
  • ✅ add emojis on messages / nested thread messages (including offline -> online transition)
  • ✅ search router functionality (CMD + K) outside of Reports page
  • ✅ reports page functionality (filtering, sorting, manually typed queries)

Expense Tracking and Submission

  • ✅ submit manual / scan / distance expense (including offline -> online transition)
  • ✅ replace scan expense receipt after it was posted (including offline -> online transition)

Pending Actions (offline -> online transitions)

  • ✅ while offline, post / update / delete a message on a report / report thread
  • ✅ while offline, submit manual / scan / distance expense
    • then delete them, then return online

User Interactions

  • ✅ mark reports read / unread (including offline -> online transition)
  • ✅ pin / unpin reports (including offline -> online transition)
  • ✅ expenses: approve / submit / mark as paid (including offline -> online transition)
  • ✅ copy onyx / export onyx data functionality
  • ✅ offline mode functionality

Summary

🎉 No issues found, E/App behaving as expected with the update in all scenarios mentioned above.

Copy link
Contributor

@neil-marcellini neil-marcellini left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's a very small start on reviewing this. I will tackle it tomorrow.

Copy link
Contributor

@lakchote lakchote left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left one NAB comment, otherwise looks good


// For native platforms we use `mergeItem` that will take advantage of JSON_PATCH and JSON_REPLACE SQL operations to
// merge the object in a performant way.
return Storage.mergeItem(key, batchedChanges as OnyxValue<TKey>, replaceNullPatches).then(() => ({
Copy link
Contributor

@lakchote lakchote Jul 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NAB: should we catch eventual errors and log them?

Also: perf tests are failing @fabioh8010

image

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NAB: should we catch eventual errors and log them?

Yep I believe so, will check this 👍

@neil-marcellini
Copy link
Contributor

Reviewing again 👀

@fabioh8010
Copy link
Contributor Author

@neil-marcellini @mountiny Extracted the removeNestedNullValues improvement into a separate PR -> #652

Copy link
Contributor

@neil-marcellini neil-marcellini left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have reviewed the whole thing before you extracted the removeNestNulls thing, so I can go and quickly review that separate PR. I will also copy over any relevant comments.

I'm requesting changes because I have a few important questions and some small tweaks I would like to see. Great job on this and especially persisting with it over time.

Also as a heads up, I'm going to be out of office from July 4-16. If we don't get this merged by then for any reason, please go ahead without my review.

Comment on lines +155 to +157
if (replaceQueryArguments.length > 0) {
commands.push({query: replaceQuery, params: replaceQueryArguments});
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NAB: Future performance optimization?: If we need to do several replacements on the same key, maybe we could pass a series of key, value pairs to the JSON_REPLACE SQLite function. Given that it supports that, I wonder if it's faster than this approach.

Not something we need to change since the performance of this PR seems to be good enough. Would be interesting to look into though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think we can explore this in a follow up PR, I will also include this if you don't mind cc @lakchote

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really like the way you wrote the tests in this file. I find them to be easy to read and track how the values are changing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

@fabioh8010
Copy link
Contributor Author

fabioh8010 commented Jul 3, 2025

@neil-marcellini Addressed/answered all comments!

Copy link
Contributor

@neil-marcellini neil-marcellini left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the updates. It all looks good to me now. I think it's good to go. We can follow up on some NAB items later.

@neil-marcellini
Copy link
Contributor

I'm going to merge this. I think it's been going on long enough and I feel like it's pretty solid at this point.

@neil-marcellini neil-marcellini dismissed stale reviews from marcaaron and chrispader July 3, 2025 18:08

Marc is OOO

@neil-marcellini neil-marcellini merged commit 82536c6 into Expensify:main Jul 3, 2025
4 of 5 checks passed
@os-botify
Copy link
Contributor

os-botify bot commented Jul 3, 2025

🚀 Published to npm in 2.0.118 🎉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants